home *** CD-ROM | disk | FTP | other *** search
/ MacHack 2000 / MacHack 2000.toast / pc / The Hacks / MacHacksBug / Python 1.5.2c1 / Tools / webchecker / webchecker.py < prev    next >
Encoding:
Python Source  |  2000-06-23  |  20.7 KB  |  678 lines

  1. #! /usr/bin/env python
  2.  
  3. """Web tree checker.
  4.  
  5. This utility is handy to check a subweb of the world-wide web for
  6. errors.  A subweb is specified by giving one or more ``root URLs''; a
  7. page belongs to the subweb if one of the root URLs is an initial
  8. prefix of it.
  9.  
  10. File URL extension:
  11.  
  12. In order to easy the checking of subwebs via the local file system,
  13. the interpretation of ``file:'' URLs is extended to mimic the behavior
  14. of your average HTTP daemon: if a directory pathname is given, the
  15. file index.html in that directory is returned if it exists, otherwise
  16. a directory listing is returned.  Now, you can point webchecker to the
  17. document tree in the local file system of your HTTP daemon, and have
  18. most of it checked.  In fact the default works this way if your local
  19. web tree is located at /usr/local/etc/httpd/htdpcs (the default for
  20. the NCSA HTTP daemon and probably others).
  21.  
  22. Report printed:
  23.  
  24. When done, it reports pages with bad links within the subweb.  When
  25. interrupted, it reports for the pages that it has checked already.
  26.  
  27. In verbose mode, additional messages are printed during the
  28. information gathering phase.  By default, it prints a summary of its
  29. work status every 50 URLs (adjustable with the -r option), and it
  30. reports errors as they are encountered.  Use the -q option to disable
  31. this output.
  32.  
  33. Checkpoint feature:
  34.  
  35. Whether interrupted or not, it dumps its state (a Python pickle) to a
  36. checkpoint file and the -R option allows it to restart from the
  37. checkpoint (assuming that the pages on the subweb that were already
  38. processed haven't changed).  Even when it has run till completion, -R
  39. can still be useful -- it will print the reports again, and -Rq prints
  40. the errors only.  In this case, the checkpoint file is not written
  41. again.  The checkpoint file can be set with the -d option.
  42.  
  43. The checkpoint file is written as a Python pickle.  Remember that
  44. Python's pickle module is currently quite slow.  Give it the time it
  45. needs to load and save the checkpoint file.  When interrupted while
  46. writing the checkpoint file, the old checkpoint file is not
  47. overwritten, but all work done in the current run is lost.
  48.  
  49. Miscellaneous:
  50.  
  51. - You may find the (Tk-based) GUI version easier to use.  See wcgui.py.
  52.  
  53. - Webchecker honors the "robots.txt" convention.  Thanks to Skip
  54. Montanaro for his robotparser.py module (included in this directory)!
  55. The agent name is hardwired to "webchecker".  URLs that are disallowed
  56. by the robots.txt file are reported as external URLs.
  57.  
  58. - Because the SGML parser is a bit slow, very large SGML files are
  59. skipped.  The size limit can be set with the -m option.
  60.  
  61. - When the server or protocol does not tell us a file's type, we guess
  62. it based on the URL's suffix.  The mimetypes.py module (also in this
  63. directory) has a built-in table mapping most currently known suffixes,
  64. and in addition attempts to read the mime.types configuration files in
  65. the default locations of Netscape and the NCSA HTTP daemon.
  66.  
  67. - We follows links indicated by <A>, <FRAME> and <IMG> tags.  We also
  68. honor the <BASE> tag.
  69.  
  70. - Checking external links is now done by default; use -x to *disable*
  71. this feature.  External links are now checked during normal
  72. processing.  (XXX The status of a checked link could be categorized
  73. better.  Later...)
  74.  
  75.  
  76. Usage: webchecker.py [option] ... [rooturl] ...
  77.  
  78. Options:
  79.  
  80. -R        -- restart from checkpoint file
  81. -d file   -- checkpoint filename (default %(DUMPFILE)s)
  82. -m bytes  -- skip HTML pages larger than this size (default %(MAXPAGE)d)
  83. -n        -- reports only, no checking (use with -R)
  84. -q        -- quiet operation (also suppresses external links report)
  85. -r number -- number of links processed per round (default %(ROUNDSIZE)d)
  86. -v        -- verbose operation; repeating -v will increase verbosity
  87. -x        -- don't check external links (these are often slow to check)
  88.  
  89. Arguments:
  90.  
  91. rooturl   -- URL to start checking
  92.              (default %(DEFROOT)s)
  93.  
  94. """
  95.  
  96.  
  97. __version__ = "$Revision: 1.18 $"
  98.  
  99.  
  100. import sys
  101. import os
  102. from types import *
  103. import string
  104. import StringIO
  105. import getopt
  106. import pickle
  107.  
  108. import urllib
  109. import urlparse
  110. import sgmllib
  111.  
  112. import mimetypes
  113. import robotparser
  114.  
  115. # Extract real version number if necessary
  116. if __version__[0] == '$':
  117.     _v = string.split(__version__)
  118.     if len(_v) == 3:
  119.         __version__ = _v[1]
  120.  
  121.  
  122. # Tunable parameters
  123. DEFROOT = "file:/usr/local/etc/httpd/htdocs/"   # Default root URL
  124. CHECKEXT = 1                            # Check external references (1 deep)
  125. VERBOSE = 1                             # Verbosity level (0-3)
  126. MAXPAGE = 150000                        # Ignore files bigger than this
  127. ROUNDSIZE = 50                          # Number of links processed per round
  128. DUMPFILE = "@webchecker.pickle"         # Pickled checkpoint
  129. AGENTNAME = "webchecker"                # Agent name for robots.txt parser
  130.  
  131.  
  132. # Global variables
  133.  
  134.  
  135. def main():
  136.     checkext = CHECKEXT
  137.     verbose = VERBOSE
  138.     maxpage = MAXPAGE
  139.     roundsize = ROUNDSIZE
  140.     dumpfile = DUMPFILE
  141.     restart = 0
  142.     norun = 0
  143.  
  144.     try:
  145.         opts, args = getopt.getopt(sys.argv[1:], 'Rd:m:nqr:vx')
  146.     except getopt.error, msg:
  147.         sys.stdout = sys.stderr
  148.         print msg
  149.         print __doc__%globals()
  150.         sys.exit(2)
  151.     for o, a in opts:
  152.         if o == '-R':
  153.             restart = 1
  154.         if o == '-d':
  155.             dumpfile = a
  156.         if o == '-m':
  157.             maxpage = string.atoi(a)
  158.         if o == '-n':
  159.             norun = 1
  160.         if o == '-q':
  161.             verbose = 0
  162.         if o == '-r':
  163.             roundsize = string.atoi(a)
  164.         if o == '-v':
  165.             verbose = verbose + 1
  166.         if o == '-x':
  167.             checkext = not checkext
  168.  
  169.     if verbose > 0:
  170.         print AGENTNAME, "version", __version__
  171.  
  172.     if restart:
  173.         c = load_pickle(dumpfile=dumpfile, verbose=verbose)
  174.     else:
  175.         c = Checker()
  176.  
  177.     c.setflags(checkext=checkext, verbose=verbose,
  178.                maxpage=maxpage, roundsize=roundsize)
  179.  
  180.     if not restart and not args:
  181.         args.append(DEFROOT)
  182.  
  183.     for arg in args:
  184.         c.addroot(arg)
  185.  
  186.     try:
  187.  
  188.         if not norun:
  189.             try:
  190.                 c.run()
  191.             except KeyboardInterrupt:
  192.                 if verbose > 0:
  193.                     print "[run interrupted]"
  194.  
  195.         try:
  196.             c.report()
  197.         except KeyboardInterrupt:
  198.             if verbose > 0:
  199.                 print "[report interrupted]"
  200.  
  201.     finally:
  202.         if c.save_pickle(dumpfile):
  203.             if dumpfile == DUMPFILE:
  204.                 print "Use ``%s -R'' to restart." % sys.argv[0]
  205.             else:
  206.                 print "Use ``%s -R -d %s'' to restart." % (sys.argv[0],
  207.                                                            dumpfile)
  208.  
  209.  
  210. def load_pickle(dumpfile=DUMPFILE, verbose=VERBOSE):
  211.     if verbose > 0:
  212.         print "Loading checkpoint from %s ..." % dumpfile
  213.     f = open(dumpfile, "rb")
  214.     c = pickle.load(f)
  215.     f.close()
  216.     if verbose > 0:
  217.         print "Done."
  218.         print "Root:", string.join(c.roots, "\n      ")
  219.     return c
  220.  
  221.  
  222. class Checker:
  223.  
  224.     checkext = CHECKEXT
  225.     verbose = VERBOSE
  226.     maxpage = MAXPAGE
  227.     roundsize = ROUNDSIZE
  228.  
  229.     validflags = tuple(dir())
  230.  
  231.     def __init__(self):
  232.         self.reset()
  233.  
  234.     def setflags(self, **kw):
  235.         for key in kw.keys():
  236.             if key not in self.validflags:
  237.                 raise NameError, "invalid keyword argument: %s" % str(key)
  238.         for key, value in kw.items():
  239.             setattr(self, key, value)
  240.  
  241.     def reset(self):
  242.         self.roots = []
  243.         self.todo = {}
  244.         self.done = {}
  245.         self.bad = {}
  246.         self.round = 0
  247.         # The following are not pickled:
  248.         self.robots = {}
  249.         self.errors = {}
  250.         self.urlopener = MyURLopener()
  251.         self.changed = 0
  252.         
  253.     def note(self, level, format, *args):
  254.         if self.verbose > level:
  255.             if args:
  256.                 format = format%args
  257.             self.message(format)
  258.     
  259.     def message(self, format, *args):
  260.         if args:
  261.             format = format%args
  262.         print format 
  263.  
  264.     def __getstate__(self):
  265.         return (self.roots, self.todo, self.done, self.bad, self.round)
  266.  
  267.     def __setstate__(self, state):
  268.         self.reset()
  269.         (self.roots, self.todo, self.done, self.bad, self.round) = state
  270.         for root in self.roots:
  271.             self.addrobot(root)
  272.         for url in self.bad.keys():
  273.             self.markerror(url)
  274.  
  275.     def addroot(self, root):
  276.         if root not in self.roots:
  277.             troot = root
  278.             scheme, netloc, path, params, query, fragment = \
  279.                     urlparse.urlparse(root)
  280.             i = string.rfind(path, "/") + 1
  281.             if 0 < i < len(path):
  282.                 path = path[:i]
  283.                 troot = urlparse.urlunparse((scheme, netloc, path,
  284.                                              params, query, fragment))
  285.             self.roots.append(troot)
  286.             self.addrobot(root)
  287.             self.newlink(root, ("<root>", root))
  288.  
  289.     def addrobot(self, root):
  290.         root = urlparse.urljoin(root, "/")
  291.         if self.robots.has_key(root): return
  292.         url = urlparse.urljoin(root, "/robots.txt")
  293.         self.robots[root] = rp = robotparser.RobotFileParser()
  294.         self.note(2, "Parsing %s", url)
  295.         rp.debug = self.verbose > 3
  296.         rp.set_url(url)
  297.         try:
  298.             rp.read()
  299.         except IOError, msg:
  300.             self.note(1, "I/O error parsing %s: %s", url, msg)
  301.  
  302.     def run(self):
  303.         while self.todo:
  304.             self.round = self.round + 1
  305.             self.note(0, "\nRound %d (%s)\n", self.round, self.status())
  306.             urls = self.todo.keys()
  307.             urls.sort()
  308.             del urls[self.roundsize:]
  309.             for url in urls:
  310.                 self.dopage(url)
  311.  
  312.     def status(self):
  313.         return "%d total, %d to do, %d done, %d bad" % (
  314.             len(self.todo)+len(self.done),
  315.             len(self.todo), len(self.done),
  316.             len(self.bad))
  317.  
  318.     def report(self):
  319.         self.message("")
  320.         if not self.todo: s = "Final"
  321.         else: s = "Interim"
  322.         self.message("%s Report (%s)", s, self.status())
  323.         self.report_errors()
  324.  
  325.     def report_errors(self):
  326.         if not self.bad:
  327.             self.message("\nNo errors")
  328.             return
  329.         self.message("\nError Report:")
  330.         sources = self.errors.keys()
  331.         sources.sort()
  332.         for source in sources:
  333.             triples = self.errors[source]
  334.             self.message("")
  335.             if len(triples) > 1:
  336.                 self.message("%d Errors in %s", len(triples), source)
  337.             else:
  338.                 self.message("Error in %s", source)
  339.             for url, rawlink, msg in triples:
  340.                 if rawlink != url: s = " (%s)" % rawlink
  341.                 else: s = ""
  342.                 self.message("  HREF %s%s\n    msg %s", url, s, msg)
  343.  
  344.     def dopage(self, url):
  345.         if self.verbose > 1:
  346.             if self.verbose > 2:
  347.                 self.show("Check ", url, "  from", self.todo[url])
  348.             else:
  349.                 self.message("Check %s", url)
  350.         page = self.getpage(url)
  351.         if page:
  352.             for info in page.getlinkinfos():
  353.                 link, rawlink = info
  354.                 origin = url, rawlink
  355.                 self.newlink(link, origin)
  356.         self.markdone(url)
  357.  
  358.     def newlink(self, url, origin):
  359.         if self.done.has_key(url):
  360.             self.newdonelink(url, origin)
  361.         else:
  362.             self.newtodolink(url, origin)
  363.  
  364.     def newdonelink(self, url, origin):
  365.         self.done[url].append(origin)
  366.         self.note(3, "  Done link %s", url)
  367.  
  368.     def newtodolink(self, url, origin):
  369.         if self.todo.has_key(url):
  370.             self.todo[url].append(origin)
  371.             self.note(3, "  Seen todo link %s", url)
  372.         else:
  373.             self.todo[url] = [origin]
  374.             self.note(3, "  New todo link %s", url)
  375.  
  376.     def markdone(self, url):
  377.         self.done[url] = self.todo[url]
  378.         del self.todo[url]
  379.         self.changed = 1
  380.  
  381.     def inroots(self, url):
  382.         for root in self.roots:
  383.             if url[:len(root)] == root:
  384.                 return self.isallowed(root, url)
  385.         return 0
  386.     
  387.     def isallowed(self, root, url):
  388.         root = urlparse.urljoin(root, "/")
  389.         return self.robots[root].can_fetch(AGENTNAME, url)
  390.  
  391.     def getpage(self, url):
  392.         if url[:7] == 'mailto:' or url[:5] == 'news:':
  393.             self.note(1, " Not checking mailto/news URL")
  394.             return None
  395.         isint = self.inroots(url)
  396.         if not isint:
  397.             if not self.checkext:
  398.                 self.note(1, " Not checking ext link")
  399.                 return None
  400.             f = self.openpage(url)
  401.             if f:
  402.                 self.safeclose(f)
  403.             return None
  404.         text, nurl = self.readhtml(url)
  405.         if nurl != url:
  406.             self.note(1, " Redirected to %s", nurl)
  407.             url = nurl
  408.         if text:
  409.             return Page(text, url, maxpage=self.maxpage, checker=self)
  410.  
  411.     def readhtml(self, url):
  412.         text = None
  413.         f, url = self.openhtml(url)
  414.         if f:
  415.             text = f.read()
  416.             f.close()
  417.         return text, url
  418.  
  419.     def openhtml(self, url):
  420.         f = self.openpage(url)
  421.         if f:
  422.             url = f.geturl()
  423.             info = f.info()
  424.             if not self.checkforhtml(info, url):
  425.                 self.safeclose(f)
  426.                 f = None
  427.         return f, url
  428.  
  429.     def openpage(self, url):
  430.         try:
  431.             return self.urlopener.open(url)
  432.         except IOError, msg:
  433.             msg = self.sanitize(msg)
  434.             self.note(0, "Error %s", msg)
  435.             if self.verbose > 0:
  436.                 self.show(" HREF ", url, "  from", self.todo[url])
  437.             self.setbad(url, msg)
  438.             return None
  439.  
  440.     def checkforhtml(self, info, url):
  441.         if info.has_key('content-type'):
  442.             ctype = string.lower(info['content-type'])
  443.         else:
  444.             if url[-1:] == "/":
  445.                 return 1
  446.             ctype, encoding = mimetypes.guess_type(url)
  447.         if ctype == 'text/html':
  448.             return 1
  449.         else:
  450.             self.note(1, " Not HTML, mime type %s", ctype)
  451.             return 0
  452.  
  453.     def setgood(self, url):
  454.         if self.bad.has_key(url):
  455.             del self.bad[url]
  456.             self.changed = 1
  457.             self.note(0, "(Clear previously seen error)")
  458.  
  459.     def setbad(self, url, msg):
  460.         if self.bad.has_key(url) and self.bad[url] == msg:
  461.             self.note(0, "(Seen this error before)")
  462.             return
  463.         self.bad[url] = msg
  464.         self.changed = 1
  465.         self.markerror(url)
  466.         
  467.     def markerror(self, url):
  468.         try:
  469.             origins = self.todo[url]
  470.         except KeyError:
  471.             origins = self.done[url]
  472.         for source, rawlink in origins:
  473.             triple = url, rawlink, self.bad[url]
  474.             self.seterror(source, triple)
  475.  
  476.     def seterror(self, url, triple):
  477.         try:
  478.             self.errors[url].append(triple)
  479.         except KeyError:
  480.             self.errors[url] = [triple]
  481.  
  482.     # The following used to be toplevel functions; they have been
  483.     # changed into methods so they can be overridden in subclasses.
  484.  
  485.     def show(self, p1, link, p2, origins):
  486.         self.message("%s %s", p1, link)
  487.         i = 0
  488.         for source, rawlink in origins:
  489.             i = i+1
  490.             if i == 2:
  491.                 p2 = ' '*len(p2)
  492.             if rawlink != link: s = " (%s)" % rawlink
  493.             else: s = ""
  494.             self.message("%s %s%s", p2, source, s)
  495.  
  496.     def sanitize(self, msg):
  497.         if isinstance(IOError, ClassType) and isinstance(msg, IOError):
  498.             # Do the other branch recursively
  499.             msg.args = self.sanitize(msg.args)
  500.         elif isinstance(msg, TupleType):
  501.             if len(msg) >= 4 and msg[0] == 'http error' and \
  502.                isinstance(msg[3], InstanceType):
  503.                 # Remove the Message instance -- it may contain
  504.                 # a file object which prevents pickling.
  505.                 msg = msg[:3] + msg[4:]
  506.         return msg
  507.  
  508.     def safeclose(self, f):
  509.         try:
  510.             url = f.geturl()
  511.         except AttributeError:
  512.             pass
  513.         else:
  514.             if url[:4] == 'ftp:' or url[:7] == 'file://':
  515.                 # Apparently ftp connections don't like to be closed
  516.                 # prematurely...
  517.                 text = f.read()
  518.         f.close()
  519.  
  520.     def save_pickle(self, dumpfile=DUMPFILE):
  521.         if not self.changed:
  522.             self.note(0, "\nNo need to save checkpoint")
  523.         elif not dumpfile:
  524.             self.note(0, "No dumpfile, won't save checkpoint")
  525.         else:
  526.             self.note(0, "\nSaving checkpoint to %s ...", dumpfile)
  527.             newfile = dumpfile + ".new"
  528.             f = open(newfile, "wb")
  529.             pickle.dump(self, f)
  530.             f.close()
  531.             try:
  532.                 os.unlink(dumpfile)
  533.             except os.error:
  534.                 pass
  535.             os.rename(newfile, dumpfile)
  536.             self.note(0, "Done.")
  537.             return 1
  538.  
  539.  
  540. class Page:
  541.  
  542.     def __init__(self, text, url, verbose=VERBOSE, maxpage=MAXPAGE, checker=None):
  543.         self.text = text
  544.         self.url = url
  545.         self.verbose = verbose
  546.         self.maxpage = maxpage
  547.         self.checker = checker
  548.  
  549.     def note(self, level, msg, *args):
  550.         if self.checker:
  551.             apply(self.checker.note, (level, msg) + args)
  552.         else:
  553.             if self.verbose >= level:
  554.                 if args:
  555.                     msg = msg%args
  556.                 print msg
  557.  
  558.     def getlinkinfos(self):
  559.         size = len(self.text)
  560.         if size > self.maxpage:
  561.             self.note(0, "Skip huge file %s (%.0f Kbytes)", self.url, (size*0.001))
  562.             return []
  563.         self.checker.note(2, "  Parsing %s (%d bytes)", self.url, size)
  564.         parser = MyHTMLParser(verbose=self.verbose, checker=self.checker)
  565.         parser.feed(self.text)
  566.         parser.close()
  567.         rawlinks = parser.getlinks()
  568.         base = urlparse.urljoin(self.url, parser.getbase() or "")
  569.         infos = []
  570.         for rawlink in rawlinks:
  571.             t = urlparse.urlparse(rawlink)
  572.             t = t[:-1] + ('',)
  573.             rawlink = urlparse.urlunparse(t)
  574.             link = urlparse.urljoin(base, rawlink)
  575.             infos.append((link, rawlink))
  576.         return infos
  577.  
  578.  
  579. class MyStringIO(StringIO.StringIO):
  580.  
  581.     def __init__(self, url, info):
  582.         self.__url = url
  583.         self.__info = info
  584.         StringIO.StringIO.__init__(self)
  585.  
  586.     def info(self):
  587.         return self.__info
  588.  
  589.     def geturl(self):
  590.         return self.__url
  591.  
  592.  
  593. class MyURLopener(urllib.FancyURLopener):
  594.  
  595.     http_error_default = urllib.URLopener.http_error_default
  596.  
  597.     def __init__(*args):
  598.         self = args[0]
  599.         apply(urllib.FancyURLopener.__init__, args)
  600.         self.addheaders = [
  601.             ('User-agent', 'Python-webchecker/%s' % __version__),
  602.             ]
  603.  
  604.     def http_error_401(self, url, fp, errcode, errmsg, headers):
  605.         return None
  606.  
  607.     def open_file(self, url):
  608.         path = urllib.url2pathname(urllib.unquote(url))
  609.         if path[-1] != os.sep:
  610.             url = url + '/'
  611.         if os.path.isdir(path):
  612.             indexpath = os.path.join(path, "index.html")
  613.             if os.path.exists(indexpath):
  614.                 return self.open_file(url + "index.html")
  615.             try:
  616.                 names = os.listdir(path)
  617.             except os.error, msg:
  618.                 raise IOError, msg, sys.exc_traceback
  619.             names.sort()
  620.             s = MyStringIO("file:"+url, {'content-type': 'text/html'})
  621.             s.write('<BASE HREF="file:%s">\n' %
  622.                     urllib.quote(os.path.join(path, "")))
  623.             for name in names:
  624.                 q = urllib.quote(name)
  625.                 s.write('<A HREF="%s">%s</A>\n' % (q, q))
  626.             s.seek(0)
  627.             return s
  628.         return urllib.FancyURLopener.open_file(self, path)
  629.  
  630.  
  631. class MyHTMLParser(sgmllib.SGMLParser):
  632.  
  633.     def __init__(self, verbose=VERBOSE, checker=None):
  634.         self.myverbose = verbose # now unused
  635.         self.checker = checker
  636.         self.base = None
  637.         self.links = {}
  638.         sgmllib.SGMLParser.__init__(self)
  639.  
  640.     def start_a(self, attributes):
  641.         self.link_attr(attributes, 'href')
  642.  
  643.     def end_a(self): pass
  644.  
  645.     def do_area(self, attributes):
  646.         self.link_attr(attributes, 'href')
  647.  
  648.     def do_img(self, attributes):
  649.         self.link_attr(attributes, 'src', 'lowsrc')
  650.  
  651.     def do_frame(self, attributes):
  652.         self.link_attr(attributes, 'src')
  653.  
  654.     def link_attr(self, attributes, *args):
  655.         for name, value in attributes:
  656.             if name in args:
  657.                 if value: value = string.strip(value)
  658.                 if value: self.links[value] = None
  659.  
  660.     def do_base(self, attributes):
  661.         for name, value in attributes:
  662.             if name == 'href':
  663.                 if value: value = string.strip(value)
  664.                 if value:
  665.                     if self.checker:
  666.                         self.checker.note(1, "  Base %s", value)
  667.                     self.base = value
  668.  
  669.     def getlinks(self):
  670.         return self.links.keys()
  671.  
  672.     def getbase(self):
  673.         return self.base
  674.  
  675.  
  676. if __name__ == '__main__':
  677.     main()
  678.